Skip to content

fix(escrow): stop losing sessions when the retrieve handoff runs late - #244

Merged
arzafran merged 2 commits into
mainfrom
fix/escrow-handoff-loss
Aug 4, 2026
Merged

fix(escrow): stop losing sessions when the retrieve handoff runs late#244
arzafran merged 2 commits into
mainfrom
fix/escrow-handoff-loss

Conversation

@arzafran

@arzafran arzafran commented Aug 4, 2026

Copy link
Copy Markdown
Member

What this does

Fixes running agent sessions dying during app relaunch (reported today: 3 workspaces updating, 1 came back as a bare shell). The reclaim protocol let the app give up on a holder after 0.5s while the holder's own handoff budget was 3.0s. A holder finishing in that gap sent the PTY fd into a connection the app had abandoned, which either killed the holder outright (uncaught SIGPIPE, taking every remaining session with it) or let the kernel destroy the fd in flight (child gets SIGHUP, dies). Either way the holder had already marked the session handed off before confirming the send, so there was no way back.

Four changes:

  1. The holder ignores SIGPIPE (process-level SIG_IGN before any socket work) and every escrow socket sets SO_NOSIGPIPE, matching the pattern TerminalController already uses. A late send now fails with EPIPE instead of killing the process.
  2. Client retrieve timeout 0.5s -> 5.0s, with the invariant documented: it must exceed the holder's 3.0s drain budget so the client never abandons a handoff the holder is still legitimately working on. Dead holders still fail fast (connect errors immediately); only an alive-but-wedged holder costs the full 5s.
  3. The session is only marked handed off after the send is confirmed. On send failure the drain state is restored, the session goes back into the registry, and a fresh drain thread resumes on the still-open fd -- recoverable on the next relaunch instead of silently lost.
  4. Release-visible diagnostics: 14 dilog call sites (escrow.retrieve / escrow.drain / escrow.conn / escrow.reattach) so the next field report comes with a timeline. Metadata only.

Review order

  1. SessionEscrow.swift handleRetrieveRequest + sendRetrieveResponse (the confirm-before-handoff reorder and re-insertion path)
  2. SessionEscrow.swift SIGPIPE/SO_NOSIGPIPE + timeout policy change
  3. Workspace+Persistence.swift (reattach outcome logging only) + the regression test

Test plan

  • CI green
  • TerminalControllerSocketSecurityTests 10/10 including the new testEscrowRetrieveResponseSendSurvivesClosedPeerWithoutCrashing (reproduces the exact bind/connect/accept/closed-peer sequence; without suppressSigPipe this provably dies with SIGPIPE, exit 141 -- verified out-of-band, documented in the test since an in-suite crash would take down the whole XCTest process, which is why this is not a two-commit red/green PR)
  • Manual: run 3 workspaces with live agents, trigger an update relaunch, all 3 revive

Three compounding defects in the session-escrow retrieve path could lose
a terminal session across an app relaunch:

- Client recv timeout (0.5s) was shorter than the holder's own per-request
  drain-stop budget (3s), so the client could abandon and close its socket
  while the holder was still doing legitimate work. Raised to 5s with an
  explicit invariant documented alongside retrieveDrainStopTimeout.
- The holder never ignored SIGPIPE and never set SO_NOSIGPIPE on its
  sockets, so a late send into an abandoned connection could raise
  SIGPIPE and kill the whole holder process, dropping every other
  escrowed session it still held. Now ignored process-wide in the
  holder's run() before any socket work, plus SO_NOSIGPIPE on every
  escrow socket (connect/bind/accept) as defense in depth.
- handleRetrieveRequest marked a session handed off before confirming the
  response send actually succeeded. sendRetrieveResponse now returns
  whether the send landed; on failure the session is reinserted into the
  registry and a fresh drain thread is started on the same fd instead of
  leaking it.

Also adds dilog (Release-safe) instrumentation across the retrieve path
so a future incident like this is diagnosable outside DEBUG builds.
Reproduces the exact socket sequence SessionEscrowHolder.run()'s accept
loop uses (bindListening -> connect -> accept -> suppressSigPipe) against
a peer that already hung up, and asserts the retrieve-response send
fails cleanly (false/EPIPE) instead of the process being killed by
SIGPIPE. Confirmed separately (not committed) that omitting
suppressSigPipe reproduces a hard SIGPIPE crash on this exact sequence;
not committed as a literal failing-first commit because that would crash
the shared XCTest process for the whole bundle, not just this test.
@arzafran

arzafran commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

cross-model review notes, for the record: two residual windows are accepted rather than fixed here. (1) a successful sendmsg still only proves the kernel queued the fd, not that the client read it; with the 5s/3s budget invariant the client no longer abandons live handoffs, so hitting this now requires the client process dying inside a microsecond window, which an ack roundtrip would only mirror, not close. (2) a holder that is alive but frozen costs 5s per session serially at launch; previously it cost 0.5s and the session. a per-socket-path circuit breaker (first timeout skips remaining retrieves against that holder) is filed as follow-up.

@arzafran
arzafran merged commit c0e9053 into main Aug 4, 2026
10 checks passed
@arzafran
arzafran deleted the fix/escrow-handoff-loss branch August 4, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant